模組四|蜜蜂、碰撞與勝負(Day 16–20)
Day 19 講過決定性:同一個 seed、同一條線,模擬會重播出同一個結果。但這句話有個前提沒被檢查:「結果」本身要先有明確的定義。
這個專案的結局規則短到可以整張貼出來(src/core/GameStateMachine.js:36-40):
| 事件 | 結果 |
|---|---|
BEE_HIT_DOG |
LOSE |
DOG_OUT_OF_BOUNDS |
LOSE |
COUNTDOWN_COMPLETE |
WIN |
順帶說死一件事:沒有危險區。 我的大綱原本寫了第三個輸的條件,查證是虛構的——全 src/ 的 danger 命中都是色票常數 COLORS.DANGER【實測:grep -rniE 'hazard|dangerZone' src/,無輸出】。
問題在:這三個事件可以落在同一個固定步長裡。蜜蜂在第 600 步碰到狗,而第 600 步剛好也是十秒的最後一步。算贏還是算輸?
結論先講:這個專案為這個情境寫了一個具名常數與一個純函式,規則寫得比多數專案清楚——而我 grep 了一次呼叫端,發現真正在跑的路徑不是它。兩套規則結論一致,但只有一套有執行者。
setTimeout先把倒數講完,因為它是「同時」這個問題的其中一方。
五關共用同一個 defenseDurationMs: 10000(src/levels/sharedLevelParts.js:148)。CountdownSystem 沒有任何計時器 API,它只有一行 this.elapsedMs += deltaMs(:50),而 deltaMs 是 GameLoop 傳下來的固定步長常數。:52 判斷倒數是否到期時,門檻比較用上了 Day 19 介紹的累加誤差容忍值 ACCUMULATION_EPSILON_MS,避免誤差讓 deadline 多跑一步。
起算點也不是進場:建構時 autoStart 預設 false(:12、:20),:22 訂閱 LINE_LOCKED 才 start()。玩家花多久畫線,不吃防守時間。這是實作階段才發現的問題,設計文件 D9 記著理由:慢一點的裝置上,蜜蜂會在防線出現前就飛過蜂巢下方。
tests/unit/CountdownSystem.test.js:105-107 直接把這個檔案的原始碼讀進來,斷言它不含牆上時鐘的呼叫。
有一件事我要說清楚,因為很容易寫成假的:分頁切到背景時倒數會停,但那不是我做的。 全 src/ 與 tests/ 沒有任何 visibilitychange 或 document.hidden【實測:grep -rn 'visibilitychange\|document.hidden' src tests,無輸出】。會停是因為 Pixi 的 ticker 走 requestAnimationFrame,而瀏覽器把背景分頁的 rAF 節流了。這是繼承來的行為,不是主動處理的。 回前景時不會爆衝補算,靠的是 MAX_FRAME_MS = 100 與 MAX_CATCH_UP_STEPS = 3(Day 10 拆過)。
GameStateMachine.handle() 的第一件事是一道守衛(:67-70):
如果目前狀態已經是 WIN 或 LOSE(:19 的 TERMINAL_STATES),直接原樣回傳,什麼都不做。四行。
這四行做掉的事比看起來多。Day 17 講過,防線是複合剛體,CollisionSystem 刻意做成無狀態、不去重,同一次撞擊碰到相鄰兩段就會 emit 兩次。這些重複事件全部撞在這道守衛上:tests/unit/GameStateMachine.test.js:44-54 連發三個 BEE_HIT_DOG,斷言結果只結算一次;:56-66 反過來驗——WIN 之後再來一個失敗事件,狀態不翻盤。
去重放在一層,而不是每個事件處理器各做一次。 這樣每加一個新的失敗條件,它自動繼承這道守衛。
但守衛處理的是「先後」:先來的贏,後來的丟掉。它處理不了「同時」,因為同時的那一刻,還沒有任何一個結果被套用。
嚴格說,EventBus.emit() 是同步的(src/core/EventBus.js:37-48),一個步長裡產生的事件仍然有先後。所以「同時」的真正意思是:一個固定步長是一個時間量子,量子內的先後是實作細節,不是遊戲規則。 任何靠執行順序決定勝負的寫法,都是在賭那個實作細節不會被改。
/**
* Events that end the level, ordered by priority.
*
* When a single fixed step produces both a failure and `COUNTDOWN_COMPLETE`,
* failure wins: a bee that reached the dog during the step reached it before
* the step's countdown tick was accounted for. `GameScene` also emits collision
* events before the countdown tick, so the two rules agree; the priority list
* makes the outcome independent of emission order regardless.
*/
export const OUTCOME_PRIORITY = Object.freeze([
GAME_EVENT.BEE_HIT_DOG,
GAME_EVENT.DOG_OUT_OF_BOUNDS,
GAME_EVENT.COUNTDOWN_COMPLETE
])
export function resolveSimultaneousOutcome(eventNames) {
return (
OUTCOME_PRIORITY.find((candidate) => eventNames.includes(candidate)) ?? null
)
}
規則是:同一步內輸贏並存,輸的贏。撐到最後一刻但被碰到,算輸。
這個決定沒有標準答案。反過來排(讓玩家撐到就算贏)完全說得通,而且更討喜。值錢的不是排序本身,是它被寫成具名常數、放在狀態機旁邊、有一段 JSDoc 說明它在解什麼問題,而不是散在某個 if/else 的先後裡。

resolveSimultaneousOutcome 在整個 repo 有 6 個命中:1 個是定義本身,另外 5 個全在 tests/unit/GameStateMachine.test.js【實測:grep -rn 'resolveSimultaneousOutcome' src tests scripts openspec】。OUTCOME_PRIORITY 只有定義那行與 :121 自己那行。
src/ 裡沒有任何生產路徑呼叫它。
真正在跑的仲裁機制是這個:
updateFixed(deltaMs) {
// Order matters and is fixed: physics (and the collision events it raises)
// first, then out-of-bounds, then the countdown. A failure inside this step
// therefore always reaches the state machine before COUNTDOWN_COMPLETE.
this.physicsManager.step(deltaMs)
settlePlayerLineBody(this.drawingSystem.playerLineBody)
this.collisionSystem.updateFixed(this.dogBody)
this.beeSystem.updateFixed(deltaMs)
this.countdownSystem.updateFixed(deltaMs)
}
src/scenes/GameScene.js:234-243。失敗事件先發、倒數最後發,配上終局守衛,結果就是「輸的贏」。規則對,但守著它的是這五行的排列順序,不是那份優先序清單。
那份 JSDoc 自己也承認了一半:它寫「GameScene 也是先發碰撞事件,所以兩條規則一致」。一致沒錯。但後半句「優先序清單讓結果與發射順序無關」,只有在有人呼叫它的時候才成立。
測試那邊有同樣的形狀。GameStateMachine.test.js:68-86 的名字叫「不管發射順序,失敗都該贏」,但它只用「失敗事件先到」這一種順序真的驅動過狀態機;「倒數先到」的順序只在 resolveSimultaneousOutcome 這個純函式層被驗過。真的讓倒數先到,狀態機會先鎖成 WIN,之後的失敗事件翻不了盤(:56-66 那個測試證明的正是這件事)。
我不打算把這寫成缺陷。這個函式是一份可執行的規格:它不改變今天的行為,但把「輸的贏」存在一個有名字、有測試、可以被引用的地方;哪天有人要調 updateFixed 的順序,這裡就是那條規則的存放處,而不是靠某個人記得。Day 16 提過的 BEE_SOFT_LIMIT 是同一類的另一端:那個是宣告了就沒下文的死常數,這個至少有測試在守語意。
這一段是我查證的時候才發現的。大綱裡完全沒有這個情境。

按下重試,onRetry 呼叫 goToScene('game', { levelId })(src/main.js:94),而 goToScene 做了一件容易漏的事:延後一幀(:53-60)。理由寫在 :48-52 的註解:場景切換是從場景自己的事件處理器裡發起的,當場 destroy 會在 Pixi 還在派送事件的時候拆掉顯示物件。
之後就是 SceneManager.goTo()(:21-46):先 destroyCurrent(),再從 factory 建一個全新的。而 factory 每次都是 new GameScene(...)(main.js:83-98)。
destroy() {
this.drawingSystem.destroy()
this.audioSystem?.destroy()
this.beeSystem.destroy()
this.countdownSystem.destroy()
this.collisionSystem.destroy()
this.stateMachine.destroy()
this.renderSyncSystem.destroy()
this.eventBus.destroy()
this.physicsManager.destroy()
this.container.destroy({ children: true })
}
十行、十個 destroy()(GameScene.js:260-271)。沒有部分重置的路徑——CountdownSystem.reset() 存在(:61-65),但全 src/ 零呼叫端【實測:grep -rn '\.reset()' src/,命中全是 PhysicsManager、DrawingSystem、Bee 的自用呼叫】。GameScene 甚至沒有 enter(),所有初始化都在 constructor 裡。
這個策略有一個隱藏好處:兩個沒被歸零的欄位不構成問題。 GameStateMachine.destroy()(:106-112)只退訂,不重置 state,測試還明講了這個行為(:115-130:destroy 之後 state 停在 SIMULATING);CountdownSystem.destroy() 也不歸零 elapsedMs。在重置策略下,這兩個都是「重試立刻判定失敗」與「倒數從上一局繼續」的來源;在重建策略下,那兩個物件根本被丟掉了。
「重置狀態」跟「重建世界」是兩種策略。前者快,但會慢慢長出漏網之魚;後者慢,但每次都乾淨。 代價是重建比重置慢。我要誠實標一句:慢多少,這個專案沒有量過。 PRD 有一個「切換低於 300 毫秒」的預算目標,但那是目標,零次量測,不是實測結果。
tests/unit/leakCycles.test.js 是 2026-08-07 才進 repo 的,它把待辦清單上那句「連續重試五十次,記錄 body 數」自動化了。CYCLES = 50,三個測試:
| 測試(行號) | 斷言 |
|---|---|
:33 五十次重試 |
每次 destroy 後 Matter body 數為 0,且五十次「執行中」的 body 數只有一種值 |
:59 五十次重試 |
Matter listener 殘留數為 0 |
:77 五十次切場景 |
stage 上恆一個容器,建立與銷毀各 50 次 |
第二個測試的註解直接引用了工程規則:AGENTS.md:56 寫「Event listeners must have matching cleanup logic」,而註解說任何大於零的殘留就是那條規則禁止的東西。**把一條寫在文件裡的規則,變成一個會失敗的斷言。**這個系列從 Day 2 講到現在,一直是同一件事。
但這個檔案自己的 JSDoc(:22-31)畫了邊界,而且畫得比我原本會畫的誠實:
These run headless, so they prove the physics/system layer releases what it allocates. They do not prove the browser reclaims Pixi textures.
所以完整的說法是:系統層放得掉自己配置的東西,五十次都是;瀏覽器回不回收得掉 Pixi 材質,沒有證據。 撰稿用的 tasks.md 4.4 標的是「headless 版已完成,真實裝置的記憶體快照仍待補」,專案 dev-log.md 證據索引第 7 項狀態是 🟡 部分【皆實查】。長時間遊玩的 texture 累積,目前不在任何測試的射程內。
還有一個真的漏了的東西,我不打算拿掉:window.__SAVE_THE_DOG_DEBUG__ 在 destroy() 裡沒有被清。寫入點是 GameScene.js:478-481,而上面那十行沒有任何一行碰它。舊場景銷毀之後、新場景的 rAF 回呼跑起來之前,全域上留著的是上一局的 metadata(要等 main.js:58 覆寫)。它只是個 debug 出口,影響有限。但「重建」策略也不是免費的:它只保證你建構過的東西會被丟掉,寫到別人身上(全域物件)的東西不算。
這段我沒有「AI 產出被哪一道閘門擋下」的紀錄可以講,一樣不編。
可以講的是一個判斷。leakCycles.test.js 是把待辦清單上一項「人工重試五十次、記下數字」的任務,換成一個 headless 測試——換之後我得到的是每次 CI 都會跑的守衛,失去的是那項任務原本要涵蓋的 texture 那一半。這種替換很容易在交接時被說成「做完了」,尤其當交出來的東西是綠色的、跑得又快。
這個檔案沒有變成那樣,原因是它的 JSDoc 裡有一句「這證明不了什麼」。驗收條件裡如果只寫「要證明什麼」,交回來的東西就會宣稱得比它證明的多;把「不涵蓋什麼」也寫進去,是我在這個專案裡覺得最划算的一種防呆。
一句話:
「不可能同時發生」的兩件事,在固定步長裡就是會同時發生。要嘛把優先序寫成資料,要嘛承認你賭的是執行順序。
三件今天就能做的事:
明天 Day 21 進模組五,第一個題目是關卡資料化。五個關卡確實是五份純資料,也確實有一個 100 行、23 個檢查的驗證器——然後我查了呼叫端:validateAll() 在整個 repo 的唯一呼叫點是一個單元測試。它守的是 CI,不是遊戲。資料化的好處不會自己發生,得有人把線接上去,而這個專案只接了一半。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
Events 文件(Events.off 在這裡)
Container.destroy() 文件
requestAnimationFrame 在背景分頁的行為
深入原理